这篇是我在组内做的一次技术分享的讲稿。没怎么修改就直接分享出来了。
Suspense
前言
React 16.6 添加了一个 <Suspense>
组件,可以用来在 lazy load 的时候显示加载中的状态。
const ProfilePage = React.lazy(() => import("./ProfilePage")) // Lazy-loaded
// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>
后来 React 想,这 Suspense 既然能用来等待 lazy load 的 Promise,其实也可以用来等待其他东西,比如请求数据的 Promise,因此就有了 Suspense for Data Fetching 这个特性。目前它仍是一个实验特性,官网上的文档也主要面向的是请求库的开发者(比如 swr
现在就适配 Suspense 了),对于大多数用户,React 官方文档仍然推荐使用 hooks 来请求数据。
是什么?不是什么?能干什么?
Suspense 是一种“等待”机制,它作为一个组件,可以让你显式地声明当等待时应该渲染什么。
Suspense 不是一个请求库。它本身并不负责创建和管理请求。
Suspense 可以让请求库与 React 深度集成,请求库可以直接“告诉” React 它正在等待响应,而无需用户手动管理 loading 状态。
怎么用?
function Post({ id }) {
const post = getPost(id)
return <article>{post}</article>
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Post id={1} />
</Suspense>
)
}
这个
getPost(id)
是…同步的?
也许你感到这个写法符合直觉却又有些困惑,这就要说到数据获取的方式。
获取数据的方式
也许你在 React 文档中看过这部分的内容,我会用与官方文档稍有不同的方式讲解。这里有两种获取数据的方式:
- 渲染后获取(传统的方式)
- 渲染即获取(Suspense 的方式)
渲染后获取
渲染后获取就是我们最常写的方式,在 componentDidMount
或者等效的 useEffect
中进行数据获取
function Post({ id }) {
const [post, setPost] = useState(null)
useEffect(() => {
fetchPost(id).then(data => setPost(data))
}, [])
if (!post) {
return <div>Loading...</div>
}
return <article>{post}</article>
}
这种方式中,<Post>
组件首先进行首次渲染,渲染完成后(componentDidMount
阶段)开始发出请求。我们再来看另一种。
渲染即获取
function Post({ id }) {
const post = getPost(id) // just works
return <article>{post}</article>
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Post id={1} />
</Suspense>
)
}
这种方式中 <Post>
渲染的同时,getPost
的请求就发出了。目前看起来渲染即获取的方式只是比渲染后获取早了一点点时间,实际上里面的区别远不仅此。
两种方式的不同
放弃了 state
你会注意到渲染即获取的方案里,是没有组件内部 state 的。这就是为什么它给人的第一印象是干净、清爽、符合直觉。<Post>
的 UI 真正的是它 props 的映射了,而不是某个 state 的映射。
可难道也就只有更纯净了这种乌托邦式的区别吗?也并不是。放弃 state 同样能够避免一些问题,说不定你也曾遇到过。
1. Waterfall
请求瀑布。看如下使用内部 state 的例子:
function Profile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser().then(data => setUser(data))
}, [])
if (!user) {
return <div>Loading profile...</div>
}
return (
<div>
<h1>{user.name}</h1>
<Posts />
</div>
)
}
function Posts() {
const [posts, setPosts] = useState()
useEffect(() => {
fetchPosts().then(data => setPosts(data))
}, [])
if (!posts) {
return <div>Loading posts...</div>
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
这个例子里,posts
要等到 user
响应之后才开始获取。
2. Race condition
考虑如下渲染后获取的例子:
function Post({ id }) {
const [post, setPost] = useState(null)
useEffect(() => {
fetchPost(id).then(data => setPost(data))
}, [id])
if (!post) {
return <div>Loading...</div>
}
return <article>{post}</article>
}
function App() {
const [id, setId] = useState(1)
return (
<div>
<button onClick={() => setId(currentId => currentId + 1)}>
Next post
</button>
<Post id={id} />
</div>
)
}
这个例子中 <App>
有一个 state,表示当前要看的文章 id,还有一个按钮用来修改这个 state。设想我们快速点击两次按钮,让 id
变成 2 再变成 3,<Post>
最终显示的是哪篇文章?由于异步请求的响应顺序无法确定,我们并不能保证 id
为 3 的时候,我们看到的就是文章 3。而没有了多余的 state,一切都变得顺畅而正确。
更早地获取数据
再讲一种情况,渲染即获取可以更早的获取数据。刚才不是讲过更早获取数据了么?这是一种不同的情况。
考虑如下例子,还是一个按钮修改 id,我们用渲染即获取的 <Post>
组件:
// Post.js
function Post({ post }) {
return <article>{post}</article>
}
// App.js
const Post = React.lazy(() => import("./Post"))
function App() {
const [id, setId] = useState(1)
return (
<div>
<button onClick={() => setId(currentId => currentId + 1)}>
Next post
</button>
<Post post={getPost(id)} />
</div>
)
}
这个例子中,我们可以同时获取 post
的内容和 <Post>
的代码,而不用等到 <Post>
下载完成才开始发起请求 post
内容。
这个 getPost(id)
是…同步的?
终于回到这个问题了,我们讲了这么多关于 Suspense for Data Fetching 所推崇的的“渲染即获取”,可究竟该怎么实现呢?
function Post({ id }) {
const post = getPost(id)
return <article>{post}</article>
}
虽然在 <Suspense>
的源码中没有找到它的实现,我们在官方示例的源码中大致推测出了它的机制:
// 不是示例的源码,但是是内意思
let post
let promise
function getPost(id) {
if (post) return post
if (promise) throw promise
promise = fetchPost(id).then(data => (post = data))
throw promise
}
没想到吧,这个 getPost(id)
方法多次调用可能返回不同的结果。它或者返回 post
的内容,或者抛出一个 Promise
。经过试验,最终得出这样一个结论:当你的组件抛出一个 Promise
时,<Suspense>
就会认为,请求正在进行,并渲染 fallback
。然后 React 调度器会适时地再次尝试渲染组件。
但这写法也太奇葩了
这就是为什么 React 关于这部分的文档是面向请求库作者,而非 React 用户的。
SWR
前言
日常开发中有时会遇到这些场景和处理方式:
相同的 URL (以及参数),缓存上一次的结果
设置一些常量来标识不同的请求,请求的结果根据标识放在 redux 里缓存,取数据的时候从 redux 取。这样来提高页面的响应效率,减少看到空页面的时间。
实际上,swr
已经帮你做好了。
是什么?不是什么?
swr
得名于 stale-while-revalidate
的缩写,它是 HTTP RFC 5861 中描述的一种 Cache-Control 扩展。大致意思是先返回缓存的响应,与此同时在后台请求新的响应,以提高响应速度,减少等待时间。虽然得名于此,swr
只是借用了它的概念,实际实现与 stale-while-revalidate
指令并无关系。
SWR 并不是一个十足的“请求库”。它主要针对的是数据获取的管理,而数据的更新、删除,它不管。
SWR is a React Hooks library for remote data fetching.
用法
import useSWR from "swr"
// fetch current user
const { data } = useSWR("/api/user")
也许你见过 SWR 这样的用法示例,心想这和普通的请求库做个 hook 没啥区别。那么我们来细细看下到底 SWR 是个什么东西。
API
const { data, error, isValidating, mutate } = useSWR(key, fetcher, options)
参数
key
: 请求的标识fetcher
: 返回请求数据的异步方法options
: 更多配置项
返回值
data
: 标识key
对应的数据error
: 加载数据过程中抛出错误isValidating
: 是否正在请求或重新验证数据mutate(data?, shouldRevalidate)
: 用于修改缓存数据
useSWR
Data Fetching
import useSWR from "swr"
async function fetchCurrentUser() {
const { data } = await axios("/api/user")
return data
}
function Profile() {
const { data: user } = useSWR("currentUser", fetchCurrentUser)
if (!user) {
return <div>Loading profile...</div>
}
return <div>{user.name}</div>
}
useSWR
方法会为你返回已缓存的标识为 currentUser
的数据,并且通过 fetchCurrentUser
获取更新的数据并存入这个标识中。
注意,SWR 并非请求库,它并非接收 URL 作为第一个参数并向其地址发送请求。从上面例子就可看出 key
并非 URL,只是用于标识一个你需要的资源。不过,比起给你的每一个资源取名字,直接用 URL 来作为标识既方便又准确。
const { data: user } = useSWR("/api/user", fetchCurrentUser)
SWR 会将 key
作为参数传给 fetcher
。既然我们使用了 URL 作为 key
,那么封装一个通用的 request
方法作为 fetcher
也是个不错的选择。
async function request(url) {
const { data } = await axios(url)
return data
}
const { data: user } = useSWR("/api/user", request)
再加上 SWR 支持全局配置默认 fetcher
,最终就变成了
const { data: user } = useSWR("/api/user")
Emmm,有内味了。看起来就像 SWR 是一个请求库一样,但你一定要清楚,其实并不是这样 😂。这一点很重要,对于你理解 SWR 的更多用法会有帮助。
Conditional Fetching & Dependent Fetching
key
传入 null
即代表不请求数据。
const { data: posts } = useSWR(user ? `/api/users/${user.id}/posts` : null)
如果你觉得这样有些反直觉,为什么 URL 为 null
就代表不发送请求?那么你就掉进了上面提到的误区。我们知道 key
是获取数据的标识,传入 null
表示我现在不取数据。
如果还是绕不清楚,换一种写法看看
async function fetchUserPosts(key) {
const userId = key.match(/^posts by user (\d+)$/)[1]
const { data } = await axios(`/api/users/${userId}/posts`)
return data
}
const { data: posts } = useSWR(
user ? `posts by user ${user.id}` : null,
fetchUserPosts
)
至此你应该不会再混淆这个概念了。
花了这么大工夫搞清楚这个概念之后,我们继续来看 SWR 的用法。刚才讲了 key
可以是一个字符串,其实 key
还接受一些其他类型。比如当它是一个函数时,SWR 会用它的返回值作为存储标识。
// function as key
const { data: user } = useSWR(() => "/api/user")
// conditional
const { data: posts } = useSWR(() =>
user ? `/api/users/${user.id}/posts` : null
)
SWR 对于函数 key
有一个特殊的处理,使得 dependent fetching 可以变得更美观流畅:
const { data: user } = useSWR(() => "/api/user")
const { data: posts } = useSWR(() => `/api/users/${user.id}/posts`)
当 user
还没有加载完时,posts
的 key
函数会抛出异常。这时 SWR 就会不加载数据,就像 key
值为 null
一样。而当 user
加载完成,posts
就会开始加载。
Multiple Arguments
除了函数,key
还可以接收一个数组。就像 useCallback
或 useEffect
的 deps
数组那样,key
数组的各个值依次相等则为相同的标识。key
数组的值会依次传入 fetcher
作为参数。
async function fetchUserPosts(_, userId) {
return axios(`/api/users/${userId}/posts`)
}
const { data: posts } = useSWR(["posts by user", userId], fetchPostsByUser)
Mutate
Manually Revalidate
前面的示例都是初次获取数据,那怎么手动再次获取某个标识的数据呢?SWR 提供了 mutate
方法。
import useSWR, { mutate } from "swr"
function Profile() {
const { data: user } = useSWR("currentUser")
return (
<div>
<h2>{user.name}</h2>
<button
onClick={() => {
mutate("currentUser")
}}
>
Refresh
</button>
</div>
)
}
当调用了 mutate(key)
,SWR 就会再次请求它对应的数据,并更新缓存。要注意,这里的 data: user
总是缓存中的内容。
或者,你也可以直接使用 useSWR
中返回的 mutate
方法,这样可以省去再次书写 key
。
function Profile() {
const { data: user, mutate } = useSWR("currentUser")
return (
<div>
<h2>{user.name}</h2>
<button
onClick={() => {
mutate()
}}
>
Refresh
</button>
</div>
)
}
Mutation
我们前面说 SWR 本身不管更新数据。但是他允许我们修改缓存的数据。用的还是 mutate
方法,它接收第二个参数,用于在重新请求之前先修改缓存的数据。
function Todo({ id }) {
const { data: todo, mutate } = useSWR(() => `/api/todos/${id}`)
async function markTodoAsDone() {
await axios.patch(`/api/todos/${todo.id}`, { done: true })
mutate({ ...todo, done: true }) // 更新缓存数据,同时重新请求数据
}
if (!todo) {
return <div>Loading...</div>
}
return (
<div>
{todo.content}
<button onClick={markTodoAsDoen}>Mark as done</button>
</div>
)
}
很多时候,更新数据的请求会直接返回更新后的资源,这时我们可能希望更新缓存的同时不用再去重新验证资源。mutate
接收第三个参数来允许控制是否重新验证资源。
const { data: updated } = await axios.patch(`/api/todos/${todo.id}`, {
done: true,
})
mutate(updated, false) // shouldRevalidate=false 表示无需重新验证资源
或者,你也可以用 Promise
来更新缓存,这也表示你不需要重新验证。
function updateTodo(id, data) {
const { data: updated } = await axios.patch(`/api/todos/${todo.id}`, data);
return updated;
}
mutate(updateTodo(todo.id, { done: true }));
Optimistic UI
也许你听说过 Optimistic UI 的概念。它描述的是当我请求更新/删除数据时,可以假设请求是成功的,并据此更新 UI;待请求完成,再根据实际结果更新 UI,这样提高页面响应速度。结合上面 mutate
的用法,其实就得到了 Optimistic UI。
mutate({ ...todo, done: true }, false) // 先乐观更新本地缓存,且不重新验证
mutate(updateTodo(todo.id, { done: true })) // 再用请求结果更新缓存
不止这些
上面只是介绍了 SWR 的一些 API 的常见用法,而 SWR 的能力可不止于此。
Focus Revalidation
当你重新聚焦到页面的时候,SWR 会自动重新验证数据。比如你在两个标签页打开了一个应用,然后在其中一个标签页修改了你的头像,当你切换到另一个标签页中时,新的头像已经加载好了。而这一机制无需你编写任何多余的代码。
Refetch on Interval
通过一个配置项开启周期性重新验证。这听起来自己实现也并不难,但别忘了,手动更新数据后重启定时器、页面离屏时暂停定时器,这些 SWR 都已帮你处理好。
从 request 到 SWR
key 的复用
我自己开发应用的时候习惯把请求按照 API 或者业务逻辑封装成一个个函数来调用,以便复用。
import { fetchUsers, updateUser } from "@/services/users"
function UserList() {
const [users, setUsers] = useState()
useEffect(() => {
;(async () => {
const { data } = await fetchUsers()
setUsers(data)
})()
}, [])
return (
<UserTable
users={users}
onEditUser={openEditUserDrawer}
// ...
/>
)
}
而在使用 SWR 的应用中,则应将 key 管理起来进行复用。
import * as resources from "@/constants/swr"
function UserList() {
const { data: users } = useSWR(resources.users)
return (
<UserTable
users={users}
onEditUser={openEditUserDrawer}
// ...
/>
)
}
在前面讲到的 Conditional Fetching 和 Data Fetching 的用法中可以看出,当使用函数作为 key
等情况下时,往往会依赖组件内的变量/常量,这就要求可复用的 key 允许传入参数,来返回正确的 key。这样一来,一些 key 是字符串,一些是函数,使得 key 的管理变得不是很优雅。
export const user = "/api/user"
// conditional
export const userPosts = user => (user ? `/api/users/${user.id}/posts` : null)
// dependent
export const userPosts = user => () => `/api/users/${user.id}/posts`
function App() {
const { data: user } = useSWR(resources.users)
const { data: posts } = useSWR(resources.userPosts(user))
}
fetcher 的复用
当你处理好了 key 的复用,你会发现他们并没能完全代替原来复用的异步方法。你仍需要管理 fetcher,因为全局 fetcher 并没能应对你所有的 key。而在 Multiple Arguments 的例子中,像查询参数这类请求的参数应当展开成数组,这就需要 fetcher 的单独支持。
export const users = (page, pageSize, orderColumn, orderType, groupId) => [
"/api/users",
page,
pageSize,
orderColumn,
orderType,
groupId,
]
async function queryUsers(
url,
page,
pageSize,
orderColumn,
orderType,
groupId
) {
return axios(url, {
page,
pageSize,
orderColumn,
orderType,
groupId,
})
}
function UserList() {
const { data: users } = useSWR(
resources.users(page, pageSize, orderColumn, orderType, groupId /*, ...*/),
queryUsers
)
return <UserTable users={users} />
}
这里 fetcher 的 url 参数耦合度也很怪。
当你又最终想办法处理好了 key 和 fetcher 的复用,你发现,@/services
目录并没有消失,更新资源的异步函数仍然在里面。到头来这部分复用并没有帮你更高效更优雅地编写应用代码。
有没有 workaround?
也不是没有。我们来看 Multiple Arguments 的参数列表耦合问题。为什么会出现这个问题?是因为在函数中无法知道 key
数组各项分别对应什么参数。如何解决,就是不把参数拆开。可是传一个对象又不行,会重复触发更新,怎么办?
export const users = params => ["/api/users", JSON.stringify(params)]
async function queryUsers(url, params) {
return axios(url, { params: JSON.parse(params) })
}
function UserList() {
const { data: users } = useSWR(resources.users(params), queryUsers)
return <UserTable users={users} />
}
用 JSON.stringify
将对象变成字符串就行了。这时你发现 queryUsers
的参数列表已经完全和调用者结耦,这部分处理 params
的机制可以被整合到全局 fetcher 里去了,不再需要单独的 fetcher。
export const users = params => ["/api/users", JSON.stringify(params)]
function UserList() {
const { data: users } = useSWR(resources.users(params))
return <UserTable users={users} />
}
机灵的你也许已经注意到,我的 key 里先进行一次 JSON.stringify
,fetcher 里再进行一次 JSON.parse
,到头来 axios 还要把它再 stringify 后拼到 URL 上去,是不是有点啰嗦?
我们可以用 qs
代替 JSON.stringify
,这样我们的 key 数组就形成了漂亮的 [url, querystring]
范式,而这恰恰是区分资源最好的标识。
async function fetcher(url, querystring) {
return axios(`${url}${querystring}`)
}
export const users = params => ["/api/users", qs.stringify(params)]
function UserList() {
const { data: users } = useSWR(resources.users(params))
return <UserTable users={users} />
}
但是使用范式会带来一点点代价。URL 相同而参数不同的情况下,会被认为是不同的资源,因此我们在表格页修改查询参数的时候,原先的查询结果无法留在界面上。
Suspense
讲了半天 Suspense,又讲了半天 SWR,他俩到底有啥关系呢?让我们回看这个问题:
这个
getPost(id)
是…同步的?
function Post({ id }) {
const post = getPost(id)
return <article>{post}</article>
}
你可能已经注意到,SWR 的 API 就实现了 Suspense 中推行的这一范式,让你摆脱 state 管理异步数据。并且,SWR 通过配置项支持了 Suspense 模式:
function Post({ id }) {
const { data: post } = useSWR(`/api/posts/${id}`, { suspense: true })
return <article>{post}</article>
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Post id={1} />
</Suspense>
)
}
当你开启了 suspense: true
,SWR 就会在数据初次加载中抛出 Promise
,触发 <Suspense>
的 fallback
。
总结
这次介绍 Suspense 和 SWR,并非推销这两个技术,推行大家去使用它们。把它们放在一起讲,是因为它们引入了异步数据使用的思想上的更新。换个角度看问题,有时问题便不再是问题。